iOS socket简单说明

日常的普通应用中如果不是对通讯的即时性有要求,基本上大家都会选择使用AFNetworking中的基于http/https的网络通信。但是有时候比如在线聊天,游戏中的数据交互等情况,对网络通信的即时性有非常高的要求(试想一下比如FPS游戏中,击中目标的数据如果不能及时传达到目标,那整个游戏的体验基本就是一塌糊涂),那么就会使用网络嵌套字socket编程,不同于http的单向的按次来算的信息发送,socket编程会要求客户端进程和服务器端进程建立起稳定的连接,然后不断进行数据交互,直到连接断开。

这里先介绍一下socket编程的相关资料

socket相关知识

HTTP和socket的区别:HTTP是一个基于TCP/IP之上的应用层协议,主要解决的是如何包装和解析数据的部分。HTTP协议规定了客户端和服务器端互相通信的规则,本身是短连接并且基于请求-响应这种方案。针对其无状态特性,一般使用的时候会通过session技术来解决问题。

而socket并不是一个协议,只是一个对TCP/IP协议的封装接口,通过调用socket才能使用TCP/IP协议,和HTTP的通信状态不同的是,socket连接属于长连接,一旦建立了连接,服务器端可以将信息推送到客户端。

创建socket连接的时候,可以指定传输层协议,可以是TCP或者UDP,当用TCP连接,该socket就是个TCP连接,反之就是UDP连接。

socket的基本原理:socket至少需要一对套接字,分别是clientSocket和serverSocket。连接分为三个步骤:

(1) 服务器监听:服务器并不具体定位客户端的套接字,而是时刻处于监听状态;

(2) 客户端请求:客户端的套接字描述要连接的服务器的套接字,提供地址和端口号,然后向服务器套接字提出连接请求;

(3) 连接确认:当服务器套接字接到客户端套接字发来的请求后,就响应客户端套接字的请求,并建立一个新的线程,把服务器端的套接字的描述发给客户端。一旦客户端确认了此描述,就正式建立连接。而服务器套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。

socket连接的特性具体表现为长连接:通常情况下socket连接就是TCP连接,因此socket连接一单建立,通讯双方开始互发数据内容,直到连接断开。在实际应用中,由于网络节点过多,在传输过程中会被节点断开连接,因此要通过轮询高速网络,该节点处于活跃状态。

socket使用的库函数:

创建套接字:

// 建立套接字并建立和地址的联系, bind建立服务端监听, listen // 建立服务器和客户端的连接```
1
2
客户端请求连接: ``` connect // 服务器端等待对应编号的socket上接收客户连接请求, accept // 发送接收数据

面向连接:

read```
1
2
释放套接字: ``` close

iOS中使用套接字

在iOS中以NSStream来管理数据流,通过Apple自己提供的API来对数据流的状态变化进行相对的管理操控。在socket的层面上,也是通过基于C语言的socket API来完成基本的嵌套字交互。

但是实际使用中很多人会使用开源的CocoaAsyncSocket这个框架来管理iOS上的嵌套字调用。

CocoaAsyncSocket:

这是基于BSD-Socket写的一个框架,给iOS以及MacOS提供了易于使用,强大的异步套接字库。将复杂的底层socket和stream操作向上以OC接口封装。

先前的框架中使用了两种方法处理底层操作:runloops版本和GCD版本,但是现在只包含GCD版本。而GCD版本中又根据TCP和UDP来分为GCDAsyncSocket和GCDAsyncUdpSocket两个类来处理(不得不吐槽一个文件里装了所有的对应功能,看起来真不太适应=。=)。

简单的socket调用操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 建立一个socket对象:
\_socket = [GCDAsyncSocket alloc]]initWithDelegate:self delegateQueue:dispatch\_get\_global\_queue(DISPATCH\_QUEUE\_PRIORITY\_DEFAULT,0)];
// 连接至指定host
NSError \*error = nil;
[\_socket connectToHost:@"111.111.111.111" onPort:1111 error:&error];
// delegate
- (void)socket:(GCDAsyncSocket \*)sock didConnectToHost:(NSString \*)host port:(unit16\_t)port {
// 代理,完成连接之后调用的方法,由调用者完成
}
- (void)socketDidDisconnect:(GCDAsyncSocket \*)sock withError:(NSError \*)err {
// 断开连接之后调用的方法,同样由调用者完成
}
- (void)socket:(GCDAsyncSocket \*)sock didWriteDataWithTag:(long)tag {
// 数据发送成功的时候调用的方法
}
...........

一个GCDAsyncSocket对象就负责一次socket通信,确定了连接的主机和端口号之后就可以创建一个socket线程并将其加入到GCD队列中。具体的真实环境下的socket调用,后面细讲,这里只说明一下大致的调用方法。

由于GCDAsyncSocket的代码量比较庞大,所以先看其头文件,看一下它整体的分为哪些功能模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
@interface GCDAsyncSocket : NSObject
// 初始化方法
- (instancetype)init;
- (instancetype)initWithSocketQueue:(nullable dispatch\_queue\_t)sq;
- (instancetype)initWithDelegate:(nullable id\<GCDAsyncSocketDelegate\>)aDelegate delegateQueue:(nullable dispatch\_queue\_t)dq;
- (instancetype)initWithDelegate:(nullable id\<GCDAsyncSocketDelegate\>)aDelegate delegateQueue:(nullable dispatch\_queue\_t)dq socketQueue:(nullable dispatch\_queue\_t)sq;
// Accepting 方法:告诉socket开始监听并且从对应端口的连接上接收信息
- (BOOL)acceptOnPort:(uint16\_t)port error:(NSError \*\*)errPtr;
- (BOOL)acceptOnInterface:(nullable NSString \*)interface port:(uint16\_t)port error:(NSError \*\*)errPtr;
- (BOOL)acceptOnUrl:(NSURL \*)url error:(NSError \*\*)errPtr;
// Connect 方法:连接到对应的host上的端口(系统底层会启用TCP三次握手来确认连接成功)
- (BOOL)connectToHost:(NSString \*)host onPort:(uint16\_t)port error:(NSError \*\*)errPtr;
- (BOOL)connectToHost:(NSString \*)host
onPort:(uint16\_t)port
withTimeout:(NSTimeInterval)timeout
error:(NSError \*\*)errPtr; // 增加了过期时间
- (BOOL)connectToHost:(NSString \*)host
onPort:(uint16\_t)port
viaInterface:(nullable NSString \*)interface
withTimeout:(NSTimeInterval)timeout
error:(NSError \*\*)errPtr;
- (BOOL)connectToAddress:(NSData \*)remoteAddr error:(NSError \*\*)errPtr;
- (BOOL)connectToAddress:(NSData \*)remoteAddr withTimeout:(NSTimeInterval)timeout error:(NSError \*\*)errPtr;
- (BOOL)connectToAddress:(NSData \*)remoteAddr
viaInterface:(nullable NSString \*)interface
withTimeout:(NSTimeInterval)timeout
error:(NSError \*\*)errPtr;
- (BOOL)connectToUrl:(NSURL \*)url withTimeout:(NSTimeInterval)timeout error:(NSError \*\*)errPtr;
// Disconnect 方法:断开连接是同步进行的,调用的时候就马上断开,任何挂起的read和write操作全部都会取消
- (void)disconnect;
- (void)disconnectAfterReading; // 可以等到所有的read操作完成后再断开
- (void)disconnectAfterWriting; // 同上,等write
- (void)disconnectAfterReadingAndWriting; // 不需要再解释了吧=。=
// Diagnostics 参数:判断一个socket是否断开连接还是在连接中,一个断开连接的socket其实是可以被重复利用,再次拿来连接和监听使用的。这里的部分全部是property,并不包含诊断所用的方法,但是这些参数是判断socket当前状态的关键
@property (atomic, readonly) BOOL isDisconnected;
@property (atomic, readonly) BOOL isConnected; // 当处于connect过程中时有可能两种状态都不是
@property (atomic, readonly, nullable) NSString \*connectedHost;
@property (atomic, readonly) uint16\_t connectedPort;
@property (atomic, readonly, nullable) NSURL \*connectedUrl;
@property (atomic, readonly, nullable) NSString \*localHost;
@property (atomic, readonly) uint16\_t localPort; // 如果disconnect,那么对应的端口或者url就应该是0或者nil
@property (atomic, readonly, nullable) NSData \*connectedAddress;
@property (atomic, readonly, nullable) NSData \*localAddress;
@property (atomic, readonly) BOOL isIPv4;
@property (atomic, readonly) BOOL isIPv6;
@property (atomic, readonly) BOOL isSecure;// socket是否被SSL/TLS保护
// Reading方法:read和write方法是不会阻塞的(异步)。当一个read完成的时候,socket:didReadData:withTag:方法会分发到delegateQueue上,同理write也有对应的方法分发。
- (void)readDataWithTimeout:(NSTimeInterval)timeout tag:(long)tag;// 开始读取socket中可读取的字节
- (void)readDataWithTimeout:(NSTimeInterval)timeout
buffer:(nullable NSMutableData \*)buffer
bufferOffset:(NSUInteger)offset
tag:(long)tag;
- (void)readDataWithTimeout:(NSTimeInterval)timeout
buffer:(nullable NSMutableData \*)buffer
bufferOffset:(NSUInteger)offset
maxLength:(NSUInteger)length
tag:(long)tag;// buffer是可以根据数据自动变化大小的
- (void)readDataToLength:(NSUInteger)length withTimeout:(NSTimeInterval)timeout tag:(long)tag;
- (void)readDataToLength:(NSUInteger)length
withTimeout:(NSTimeInterval)timeout
buffer:(nullable NSMutableData \*)buffer
bufferOffset:(NSUInteger)offset
tag:(long)tag;
- (void)readDataToData:(NSData \*)data withTimeout:(NSTimeInterval)timeout tag:(long)tag;
- (void)readDataToData:(NSData \*)data
withTimeout:(NSTimeInterval)timeout
buffer:(nullable NSMutableData \*)buffer
bufferOffset:(NSUInteger)offset
tag:(long)tag;
- (void)readDataToData:(NSData \*)data withTimeout:(NSTimeInterval)timeout maxLength:(NSUInteger)length tag:(long)tag;
- (void)readDataToData:(NSData \*)data
withTimeout:(NSTimeInterval)timeout
buffer:(nullable NSMutableData \*)buffer
bufferOffset:(NSUInteger)offset
maxLength:(NSUInteger)length
tag:(long)tag;
- (float)progressOfReadReturningTag:(nullable long \*)tagPtr bytesDone:(nullable NSUInteger \*)donePtr total:(nullable NSUInteger \*)totalPtr;
// Writing 方法:
- (void)writeData:(NSData \*)data withTimeout:(NSTimeInterval)timeout tag:(long)tag;
- (float)progressOfWriteReturningTag:(nullable long \*)tagPtr bytesDone:(nullable NSUInteger \*)donePtr total:(nullable NSUInteger \*)totalPtr;
// Security 方法:使用SSL/LTS来保证连接的安全性
- (void)startTLS:(nullable NSDictionary \<NSString\*,NSObject\*\>\*)tlsSettings;
// 一个方法,但是内容比较复杂,字典包含很多可选择的key,比如GCDAsyncSocketManuallyEvaluateTrust, - GCDAsyncSocketUseCFStreamForTLS, - kCFStreamSSLPeerName等
/\* 高级方法 :传统的socket在对话结束之前是不会关闭的,但是技术上可以让远端的点关闭write stream,然后本地的socket就会发现已经没有信息被读入了,但是它任然处于可以被写入信息的状态,并且远程端点仍然可以继续接受本地发送过去的信息。
所以需要能让本地的客户端能够主动关闭write stream,并且通知服务器端说“我们不会再发送更多信息过去了”。
更糟糕的是,从TCP的层面,并不能分清楚到底是一段read stream结束还是整个socket结束了,它们都会让TCP栈收到一个FIN包,唯一的区别方法只能是看有没有继续发送的数据(如果是socket结束了,服务器会发送一个RST包)。
为了解决这个问题,APPLE的方法是当read stream被关闭之后,系统API马上请求将socket也关闭,听起来很简单粗暴,但是实际上挺简单有用的。下面这些参数保证了这些逻辑
\*/
@property (atomic, assign, readwrite) BOOL autoDisconnectOnClosedReadStream;// 默认是YES,如果服务器是自己维护的,或许可以考虑改为no,并且完成socketDidCloseReadStream方法。
// GCDAsyncSocket通过一个内含的serial dispatch\_queue来维护现成安全,通常由实例本身来维护这个队列,然而也可能在初始化这个实例的时候就将队列作为参数交给它了。这样外部能通过操控socket的线程优点度来完成一些有技巧性的操作=。=但是也会带来一些死锁的烦恼,所以需要谨慎操作,比如当改变了一个实例的targetQueue,但是一些操作需要在以前的queue上执行的时候。这个问题主要怪GCD的API没有办法找到一个queue的targetQueue。
- (void)markSocketQueueTargetQueue:(dispatch\_queue\_t)socketQueuesPreConfiguredTargetQueue;
- (void)unmarkSocketQueueTargetQueue:(dispatch\_queue\_t)socketQueuesPreviouslyConfiguredTargetQueue;
- (void)performBlock:(dispatch\_block\_t)block;
- (int)socketFD;
- (int)socket4FD;
- (int)socket6FD;
\#if TARGET\_OS\_IPHONE
- (nullable CFReadStreamRef)readStream;
- (nullable CFWriteStreamRef)writeStream;
- (BOOL)enableBackgroundingOnSocket;
- (nullable SSLContextRef)sslContext;
// 辅助功能:一些辅助性的功能函数,全都是class method,并且推荐在background thread中使用
+ (nullable NSMutableArray \*)lookupHost:(NSString \*)host port:(uint16\_t)port error:(NSError \*\*)errPtr;
+ (nullable NSString \*)hostFromAddress:(NSData \*)address;
+ (uint16\_t)portFromAddress:(NSData \*)address;
+ (BOOL)isIPv4Address:(NSData \*)address;
+ (BOOL)isIPv6Address:(NSData \*)address;
+ (BOOL)getHost:( NSString \* \_\_nullable \* \_\_nullable)hostPtr port:(nullable uint16\_t \*)portPtr fromAddress:(NSData \*)address;
+ (BOOL)getHost:(NSString \* \_\_nullable \* \_\_nullable)hostPtr port:(nullable uint16\_t \*)portPtr family:(nullable sa\_family\_t \*)afPtr fromAddress:(NSData \*)address;
// 最后是delegate
@protocol GCDAsyncSocketDelegate \<NSObject\>
@optional
- (nullable dispatch\_queue\_t)newSocketQueueForConnectionFromAddress:(NSData \*)address onSocket:(GCDAsyncSocket \*)sock;// 在socket:didAcceptNewSocket:之前就会调用,为新接受的socket提供socketQueue,如果没有实现或者返回Nil那么新的socket就会自己创建默认queue
- (void)socket:(GCDAsyncSocket \*)sock didAcceptNewSocket:(GCDAsyncSocket \*)newSocket; // 当一个socket接受一个连接时调用,创建另外一个socket来处理这个连接。必须retian这个新的socket以免被release的时候丢失连接,这个新的socket默认使用相同的delegateQueue
- (void)socket:(GCDAsyncSocket \*)sock didConnectToHost:(NSString \*)host port:(uint16\_t)port;
- (void)socket:(GCDAsyncSocket \*)sock didConnectToUrl:(NSURL \*)url;
- (void)socket:(GCDAsyncSocket \*)sock didReadData:(NSData \*)data withTag:(long)tag;
- (void)socket:(GCDAsyncSocket \*)sock didReadPartialDataOfLength:(NSUInteger)partialLength tag:(long)tag;
- (void)socket:(GCDAsyncSocket \*)sock didWriteDataWithTag:(long)tag;
- (void)socket:(GCDAsyncSocket \*)sock didWritePartialDataOfLength:(NSUInteger)partialLength tag:(long)tag;
// 这些方法名上都很直观就是了=。=
- (NSTimeInterval)socket:(GCDAsyncSocket \*)sock shouldTimeoutReadWithTag:(long)tag
elapsed:(NSTimeInterval)elapsed
bytesDone:(NSUInteger)length;// 如果一个read操作在超时之后还没有完成,这个方法能让用户自由拓展超时时间,注意一个操作有可能调用这个函数很多次,elapsed是超过的总时间
- (NSTimeInterval)socket:(GCDAsyncSocket \*)sock shouldTimeoutWriteWithTag:(long)tag
elapsed:(NSTimeInterval)elapsed
bytesDone:(NSUInteger)length; // 同上,这里是write操作的超时处理
- (void)socketDidCloseReadStream:(GCDAsyncSocket \*)sock; //当read stream被关闭但是write stream仍然可以操作时
- (void)socketDidDisconnect:(GCDAsyncSocket \*)sock withError:(nullable NSError \*)err;
- (void)socketDidSecure:(GCDAsyncSocket \*)sock; // 当SSL/TLS诊断完成后调用(如果没有成功socket会直接关闭)
- (void)socket:(GCDAsyncSocket \*)sock didReceiveTrust:(SecTrustRef)trust
completionHandler:(void (^)(BOOL shouldTrustPeer))completionHandler;

TCP/IP浅谈

知道了socket其实是TCP/UDP的底层API实现之后,我觉得有必要稍微了解一下TCP/IP协议的特性。当然这里就不得不提到大多数人都会看的那本最经典的<< TCP/IP详解>>,但是里面的内容太过于充实,TCP/IP整个协议群如果真的想认真了解,无疑是一件无比繁重的工作,光是看着目录里那些冗长的协议名就已经要了我半条命=。=所以按照我的粗浅了解,这里只能归纳一下一些众所周知的TCP/IP特性:

1. TCP Header:

众所周知,网络传输的其实是信息流,端口之间不断发送和接受数据流,问题的关键在于必须有一个很明确的方法解读这些数据,比如怎么把很多次传过来的数据拼成本来应该是一个完整的数据。这时候就会考虑到给每个报文段加上序号,告诉系统接受到的信息从哪里开始是描述性的数据,从哪里开始是真是有用的数据内容,然后一段数据和系统里已经有的数据如何拼凑成一个整体。这些约定的解读方法全部在TCP Header中实现了。

包含source port(16位)和destination port(16位),sequence number(32位)报文段的序号来表示顺序,acknowledgment number(32)代表发送方已接受到的报文段,并且期望接受到的下一个报文段的开始序号。这里我们可以看出TCP协议并不关心报文段的个数,而是传输的字节的个数,毕竟我们是按照字节的序号进行数据拼接。

其次有一些对齐位,保留位等标记。重点是后面的flag位:包含urgent,ack,push,reset,syn和fin这六位。urgent表示本报文为紧急数据,reset是异常连接结束或端口错误的标记,而ack确认,syn同步和fin结束是三次握手中的关键标记位。push表示TCP不再等待其它报文段到达,马上交给上层应用层。

window窗口数据,是流量控制的关键部分;check sum验证报文段是否受损;options中包括占位符,计算往返时间使用的时间戳还有传输的最大比特MTU。

2. 三次握手

三次握手只能由客户端向服务端发起,第一次客户端发送SYN为1以及序列号seq1,表示想建立连接;然后服务端返回ACK,SYN都为1,序列号seq2,确认序列号seq1+1,表示也想建立联系;第三步由客户端接收后发送ACK为1,并且返回确认序号seq2+1。至此建立连接。

试想一下如果没有三次握手,有可能客户端的第一个报文经过了很长的时间才到达服务器端,这个应该被认为失效的报文不仅没有失效,反而会开启一个新的连接,这样就会出错。所以三次握手可以确保传输数据的两方都处于正常状态下才开始进行传输。

那么如果第三次失败了的话会直接取消连接吗?并不会,服务器端一般会有一个默认的等待间隔,一般是1s,再重新发送一次确认报文,这样重复5次,每次的间隔时间都比上一次更长(一般是两倍),全部失败之后才选择放弃连接。

3. 四次挥手

用来断开连接的过程,两方都可以发起。第一次发送方发送FIN为1,ACK为1,以及序列号seq1。第二步接收方发送ACK为1,确认序列号seq1+1,表示正在结束连接;第三部还是接收方发送FIN为1,ACK为1,序列号为seq2,请求结束另一方的连接;最后发送方收到后发送ACK为1,确认序列号seq2+1,断开连接。

其实我们可以发现这是一个两方重复的过程,因为要断开两边各自的通信所以两边换了一下角色完成了同样的工作:发送FIN,ACK和序列号,然后等待对方回复ACK和序列号+1,这样就断开了一端的连接。完成两端一共就四次挥手了,很好理解。如果不到四次的话,肯定有一端的关闭没有完全确认,可能会造成有一端关闭了通讯但是另一端一直在接着传输数据的这种错误,也就是俗称的半关闭状态。

4. 流量控制

流量控制实际上是发送方和接收方处理速度匹配的过程。实际上当一端收到报文的时候并不是直接就对报文的内容进行处理,而是会将数据放在缓冲中等待应用程序取出。这样的情况下如果传输过程中一端没有及时处理完报文段,就会造成数据缓冲溢出。所以我们需要流量控制,接收方会将自己的缓冲剩余空间rwnd告诉发送方,发送方为了控制速度,只能再发送得到的剩余空间容量之内的报文段。由于TCP的确认方式,发送方得到的容量不会限制已发送而未确认的报文段,所以有可能报文段已经在接收方的缓冲中但是发送方并不知道,只有将要发送的报文才能得到限制。

而当容量剩余为0时并不会停止发送报文,TCP准备了一个计时器,当填满缓冲的时候会启动计时器,然后不断发送剩余空间探测报文(window probe),直到有多的剩余空间。

5. 拥塞控制

TCP并不只是单纯的完成握手和数据对接这些工作,它还负责调控网络传输的速率。试想一下如果所有的网络传输进程全部一股脑地把东西全部怼到接收方,肯定会出事。而TCP就有一系列的判断方法来处理这些事情,由于我们知道TCP并不能从全局上判断网络环境状况,所以它只能通过当前进程通信的状态来调整自己的节奏。

TCP对是否发生拥塞情况有一套自己的判断方法:只要出现超时重传和3次冗余ACK引起的快速重传就认为是网络拥塞了;而网络拥塞也有程度的区分,如果超时重传就认为是拥塞程度强,快速重传就认为程度相对比较弱;而ACK只要不出现冗余,那么就认为一切顺利;而当每次发生拥塞后,TCP会记录下导致发生拥塞的报文段的数量的一半,下次如果发生了这个数量的冗余,就判定为快要拥塞了;而对于出现了快速重传的情况,说明发生了拥塞但是并不严重,那么就适当降低cwnd,表示“出了问题,请小心一点”。

可以认为TCP对拥塞的调控就是在“慢启动”,“拥塞避免”和“快速恢复”这三个状态中来回切换,没遇到问题的时候就不停指数加速,有可能拥塞的时候就放慢加速的速度,出现不严重的拥塞就减速,出现非常严重的拥塞就直接从零开始。

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器